主题
Go 函数:看清参数何时传入,代码何时执行
把函数传给另一个函数、返回一个闭包、在退出前执行清理,这几件事在 Go 里都很常见。难点往往不在语法,而在时间:函数拿到的是哪个值,闭包后来读到的是哪个变量,return 与 defer 谁先改结果。
这篇从普通调用开始,再把执行时间往后移,依次讲函数值、闭包和 defer。读完应该能判断一段函数代码的输出,也知道文件关闭、回调和循环里最容易出错的地方。示例实际运行于 Go 1.27.2、Linux/amd64;完整程序、测试和故意无法编译的反例放在文末。
先认清几种写法在做什么
| 写法 | 它表达什么 | 典型用途 |
|---|---|---|
func divide(a, b int) (int, int) | 一个函数接收两个整数,返回两个整数 | 同时返回商和余数 |
func(int) int | 函数类型:输入一个整数,输出一个整数 | 保存或传递变换规则 |
func(n int) int { return n + 2 } | 函数字面量,产生一个函数值 | 写一个短回调 |
func counter() func() int | 函数返回另一个函数 | 把状态和后续操作放在一起 |
func total(xs ...int) int | 最后一个参数接收数量可变的整数 | 聚合一组同类型参数 |
defer f(x) | 现在确定 f 和 x,退出当前函数前调用 | 资源清理 |
这几种写法可以组合使用。比如 defer func() { ... }() 同时用了函数字面量、函数调用和延迟执行。先拆开各自的规则,比把整段代码背下来容易。声明、函数类型和调用规则以语言规范为准。[1]
多返回值:一次调用把相关结果交齐
商和余数来自同一次除法,分别返回很自然:
go
func divmod(a, b int) (int, int) { return a / b, a % b }
q, r := divmod(17, 5)
fmt.Println(q, r)实际输出:
text
3 2(int, int) 是两个返回值,并没有在这里声明一个可以到处传递的“元组类型”。调用方用两个变量接住;确实不需要余数时,可以写 q, _ := divmod(17, 5)。同样的机制也用于 (value, error) 和 (value, bool)。前者通常让调用者处理失败,后者常用于表达“找到没有”;具体含义由函数的 API 约定,而非编译器替它决定。[2]
这个 divmod 为了集中演示返回值,要求 b != 0。整数除零会 panic;需要接收不可信输入的实际函数,应先验证除数,再选择返回错误或其他约定。别因为例子短,就把它当作完成了所有输入检查。[3]
命名返回值是给结果变量起名,例如 func bounds() (low, high int)。这些名字位于函数作用域内,进入函数时先取零值;没有表达式的 return 会返回它们当前的值。短函数里名字能说明两个整数各是什么,长函数里裸 return 却可能让读者到处找最后一次赋值。本文通常保留显式 return,只在需要演示结果变量如何被 defer 修改时使用它。[2:1]
函数也是值:把变化的规则传进去
所谓“一等公民”,在这里有很具体的含义:函数值可以赋给变量、作为参数,也可以作为结果返回。函数类型描述参数和结果的类型;参数名不影响类型是否相同,是否为可变参数则会影响。[1:1]
go
type Transform func(int) int
func applyTwice(n int, f Transform) int { return f(f(n)) }
fmt.Println(applyTwice(3, func(n int) int { return n + 2 }))text
7applyTwice 负责“执行两次”,传入的函数负责“每次怎么变换”。这里先把 3 变成 5,再把 5 变成 7。排序时传比较函数、测试时传替代实现,也是在分开稳定的流程和变化的动作。对只有一个动作的需求,函数参数通常已经够清楚;需要几个共同操作或一个有明确身份的对象时,再考虑接口。这是组织代码的取舍,不是语言强制的设计模式。[1:2]
函数值有两个必须记住的边界:
| 操作 | 是否允许 | 原因或结果 |
|---|---|---|
var f func() | 允许 | 零值为 nil |
f == nil | 允许 | 可以检查可选回调是否存在 |
调用 nil 的 f() | 编译允许,运行时 panic | 没有可调用的函数 |
f == g | 编译不通过 | 函数值只能与 nil 比较 |
把 func(int) int 传给要求 func(int64) int64 的参数 | 不会自动适配 | 参数和结果类型不同 |
因此,回调是否可以为 nil,要在接口约定中讲清楚。若回调是必填参数,静默跳过它可能掩盖错误;若它是可选钩子,调用前检查 f != nil 就有意义。不要靠取函数代码地址来判断两个闭包“是不是同一个”;语言没有提供这种函数相等关系。[4]
普通函数调用按值传参。传入 slice 或指针时,复制的是 slice 值或指针值,它们仍可能指向调用者的数据。这一点到了可变参数和闭包里同样成立。[5]
闭包保留变量,后续调用会继续用它
一个匿名函数使用外层作用域的变量,就能把那份状态留给后续调用:
go
func counter(start int) func() int {
n := start
return func() int { n++; return n }
}
a, b := counter(10), counter(10)
fmt.Println(a(), a(), b())text
11 12 11a 的两次调用访问同一个 n,所以第二次得到 12。另一次调用 counter(10) 建立了另一份 n,b 第一次仍得到 11。外层函数返回不会让被闭包引用的变量立即失效;规范要求它们在仍可访问时保持存活。这不等于“每个闭包都必须堆分配”,存储位置是编译器实现问题。[6]
闭包通常读的是变量,不是创建那一刻的值快照。 如果外层后来改变变量,闭包再运行时就可能看到变化。要固定某个值,可以把它作为普通参数传入另一个调用,让新参数保存那一刻的值;但若这个值本身是指针或 slice,复制它仍不会自动深拷贝指向的数据。[6:1][5:1]
这还意味着一个计数闭包本身并不自动具备并发安全性。两个 goroutine 没有同步地读写同一个 n,会形成数据竞争。需要共享计数时,应使用互斥或合适的原子操作;这里只演示串行调用。[7]
循环里要看变量在哪里声明
Go 1.22 起,使用新语言版本语义时,for 语句自己声明的循环变量在各次迭代中独立。复用循环外已经声明的变量,仍然是另一回事:[8]
go
var fresh, shared []func() int
for i := 0; i < 3; i++ {
fresh = append(fresh, func() int { return i })
}
var i int
for i = 0; i < 3; i++ {
shared = append(shared, func() int { return i })
}
fmt.Println(fresh[0](), fresh[1](), fresh[2]())
fmt.Println(shared[0](), shared[1](), shared[2]())text
0 1 2
3 3 3第一段的 := 在循环里声明变量;第二段的 = 给外面的同一个 i 反复赋值,循环结束时它是 3。因而不能把旧教程里的“全部输出 3”照搬到所有新代码,也不能把新规则理解为“循环里的所有共享引用都安全了”。变量独立,不代表变量指向的对象也独立。文末模块声明 go 1.27.0,所以第一段采用新语义。[8:1]
可变参数:函数里拿到的是 slice
func total(xs ...int) int 允许调用者给零个、一个或多个整数。进入函数后,xs 的类型是 []int;不给参数时它是 nil slice。已有 []int 时,用 xs... 把它交给可变参数。[9]
go
xs := []int{1, 2, 3}
fmt.Println(total(), total(1, 2, 3), total(xs...))
replaceFirst(xs...) // 函数内执行 xs[0] = 99
fmt.Println(xs)text
0 6 6
[99 2 3]第二个输出说明,xs... 没有复制一份元素。函数拿到的 slice 与原 slice 共用底层数组,改元素会影响调用者。如果 API 应该保留调用者的数据,应先复制元素,或让函数承诺只读;别把 ... 当作隔离边界。[9:1]
| 调用形式 | 是否可用 | 需要记住的点 |
|---|---|---|
total() | 可以 | 函数必须能处理零个参数 |
total(1, 2, 3) | 可以 | 参数类型都是 int |
total(xs...),其中 xs 是 []int | 可以 | 使用原 slice,不自动复制元素 |
total(1, xs...) | 不可以 | 不能把同一个可变参数部分写成单个参数再加展开 slice |
fmt.Println(names...),其中 names 是 []string | 不可以 | []string 不是 []any |
最后一行容易误判:一个 string 可以赋给 any,不表示整个 []string 可以直接当 []any。需要逐个转换时,就创建 []any 并逐个赋值;只是要把这组字符串打印成一个切片,写 fmt.Println(names) 即可。编译器在文末三个反例中明确拒绝了不支持的写法。[9:2][10]
defer:登记发生在现在,调用发生在函数退出前
defer 最适合把“获得资源”和“安排释放资源”放在相邻位置。例如打开文件成功后再登记关闭,避免后面新增一条提前返回路径时漏掉清理。规则并不复杂,但必须拆成两个时间点:[11]
- 执行到
defer f(x)时,求出并保存函数值f及参数x。 - 当前函数准备返回,或因 panic 开始退出时,调用已登记的函数;后登记的先执行。
看这一段:
go
func deferred() (result int) {
n := 1
defer fmt.Println("argument:", n)
defer func() { fmt.Println("closure:", n); result++ }()
n = 2
return 10
}
fmt.Println("named result:", deferred())text
closure: 2
argument: 1
named result: 11两个 defer 看到不同的 n,并不矛盾。第一个在登记时就把参数 1 保存下来了。第二个登记的是一个闭包,它执行时才从外层变量读取 n,此时值已经变成 2。又因为它最后登记,所以最先执行。[11:1][6:2]
命名结果的变化可以按这条时间线阅读:
正在绘制图示…
查看图源代码
flowchart TD
A["进入函数:result = 0,n = 1"] --> B["登记 Println,保存参数 n 的值 1"]
B --> C["登记闭包,闭包引用 n 和 result"]
C --> D["n = 2"]
D --> E["return 10:先把 10 赋给 result"]
E --> F["后登记的闭包先执行:打印 2,result 变成 11"]
F --> G["先登记的 Println 执行:打印已保存的 1"]
G --> H["函数完成返回:调用方得到 11"]
return 10 并不意味着调用方立刻拿到 10。规范规定,返回表达式先求值并赋给结果参数,然后才运行延迟调用。于是闭包还能修改命名结果变量。对照下面的函数:[2:2][11:2]
go
func unnamed() int {
n := 10
defer func() { n++ }()
return n
}
fmt.Println(unnamed())text
10这里 n 是普通局部变量。return n 已经把 10 交给结果位置,之后把局部的 n 改成 11,不会回头修改那个返回值。这个区别也说明:用 defer 改返回错误时,要明确修改的是结果变量,不能只修改一个已经被复制出去的局部变量。[2:3][11:3]
保存的不只是参数,还有函数值
go
f := func() { fmt.Println("function: old") }
defer f()
f = func() { fmt.Println("function: new") }所在函数退出时实际输出:
text
function: olddefer f() 保存了原来的函数值,后来给 f 赋新值不会改变已登记的调用。还有一个容易漏掉的边界:若保存的是 nil 函数值,panic 发生在真正执行延迟调用时,而非执行 defer 语句时。文末测试验证了这个时间差。[11:4]
defer fmt.Println(time.Since(start)) 一类写法也要按同样规则阅读:作为参数出现的函数调用会当场求值。要在退出时测耗时,就把 time.Since(start) 放进延迟执行的闭包里。这里是在应用语言求值规则,并没有声称测过某种 defer 开销。[11:5]
defer 绑定的是函数,不是循环体
同一个函数里,每次循环登记的 defer 都会等到该函数退出。下面用事件记录代替真实文件,方便看出清理时机:
go
func deferScope() (events []string) {
for i := 0; i < 3; i++ {
defer func(n int) { events = append(events, fmt.Sprint("close ", n)) }(i)
events = append(events, fmt.Sprint("use ", i))
}
events = append(events, "loop done")
return
}
func deferPerItem() (events []string) {
for i := 0; i < 3; i++ {
func(n int) {
defer func() { events = append(events, fmt.Sprint("close ", n)) }()
events = append(events, fmt.Sprint("use ", n))
}(i)
}
return
}分别打印这两个函数的返回值,实际输出:
text
function scope: [use 0 use 1 use 2 loop done close 2 close 1 close 0]
per item: [use 0 close 0 use 1 close 1 use 2 close 2]第一行在循环体里直接登记清理,直到循环全部完成才倒序执行。第二行把每一项工作放进单独调用的函数,单次调用结束就清理。两种完整写法都在文末。批量处理文件或连接时,如果希望每一项处理后尽快释放资源,提取 processOne 辅助函数通常更清楚,也不会把大量资源留到整个外层函数结束。[11:6]
延迟函数的返回值会被丢弃,所以单独写 defer f.Close() 不会替你检查关闭错误。是否需要保留该错误取决于操作契约;写文件时若关闭失败也有意义,可以用闭包检查它,并结合主操作错误一起返回。defer 还不是“进程无论如何终止都执行”:os.Exit 会立即退出而不运行它。[11:7][12]
recover:必须由延迟执行的函数直接调用
recover 的意义要放在 panic 的退出过程里理解。它要在同一个 goroutine 中,由正在执行的延迟函数直接调用,才能停止相应的 panic。恢复后,含有该延迟函数的函数结束并返回给调用者;不会跳回 panic(...) 后面继续执行。[13]
| 写法 | 能否恢复当前 panic | 关键区别 |
|---|---|---|
defer func() { recover() }() | 能,满足其他条件时 | 延迟函数自己调用 recover |
defer cleanup(),cleanup 内直接调用 recover | 能,满足其他条件时 | 延迟函数可以是有名字的函数 |
defer func() { helper() }(),由 helper 调用 recover | 不能 | 中间多了一层普通调用 |
defer recover() | 不能 | 没有在延迟函数体内直接调用 |
| 在父 goroutine 的 defer 中捕获子 goroutine 的 panic | 不能 | 不是同一个 goroutine |
先看最容易写错的“间接调用”分支。这里保留最外层兜底,避免示例直接崩溃;完整程序还包含表中另外两个分支:
go
func recoverHelper() any { return recover() }
func indirect() (events []string) {
defer func() { // 先登记,后执行;这里直接调用 recover
if p := recover(); p != nil {
events = append(events, "outer: "+fmt.Sprint(p))
}
}()
defer func() { // 后登记,先执行;这里只调用辅助函数
events = append(events, "helper: "+fmt.Sprint(recoverHelper()))
}()
panic("boom")
}它对应下面输出的第二行。完整程序通过 recovery(kind) 选择三种写法,实际输出是:
text
direct recovery: [direct: boom]
helper recovery: [helper: <nil> outer: boom]
builtin recovery: [outer: boom]第二行最有解释力:辅助函数里的 recover() 得到 nil,panic 仍然存在,随后由外层直接调用的 recover() 捕获。不要为了复用,把正确的恢复逻辑再包进一个普通辅助函数。defer 的函数可以叫辅助函数,但 recover() 必须就写在它的函数体里。[13:1]
能恢复,不代表恢复后就能继续提供一个可信结果。对于正常可预期的失败,优先设计错误返回;对于 panic,在明确的任务或请求边界恢复、记录必要信息,并返回明确的失败。具体如何区分和包装错误,见《Go 错误处理》。本文测试的是控制流语义,没有把“捕获到了 panic”当作“状态已经恢复正确”。[14]
依据与验证范围
正文的基础是 Go 语言规范,运行结果用于核对具体示例。当前规范和官方 Go 1.27.2 发行包中的 doc/go_spec.html 已交叉核对。循环规则需要注意项目声明的语言版本。
本次在 2026-10-13 用 Go 1.27.2、Linux/amd64 执行了以下检查:
go run .:正文全部示例输出与完整程序一致;正文少数片段省略了完整程序中的输出标签。go test -count=1 -v .:5 项测试通过,覆盖恢复调用位置与就近间接恢复片段、nil 延迟函数的 panic 时机、循环变量与闭包独立状态、可变参数共享。go test -race -count=1 .:通过。这里的测试是串行语义测试,不证明计数闭包可并发调用。go vet .:通过。- 3 个
testdata反例分别编译失败,错误摘要见后面的运行命令。
没有测 defer 或闭包的性能,也没有推断它们必然分配在哪种存储区域。os.Exit 不执行 defer 依据官方文档,本篇没有另启进程演示退出。
完整可复现代码
把下列文件放进同一个目录。testdata 里的代码故意不合法,不会被正常的 go test . 当成业务包编译。
运行:
sh
go version
go run .
go test -count=1 -v .
go test -race -count=1 .
go vet .
# 以下 3 条都应该返回非零退出码
go build ./testdata/compare
go build ./testdata/mixed
go build ./testdata/anyslice本次三个反例的关键诊断分别为:
text
invalid operation: f == f (func can only be compared to nil)
too many arguments in call to sum
cannot use xs (variable of type []string) as []any value in argument to fmt.Printlngo.mod
go.mod
module example.com/go-functions
go 1.27.0main.go
代码块 · 133 行 · 点击收起 / 展开
go
package main
import "fmt"
type Transform func(int) int
func applyTwice(n int, f Transform) int { return f(f(n)) }
func divmod(a, b int) (int, int) { return a / b, a % b }
func counter(start int) func() int {
n := start
return func() int { n++; return n }
}
func total(xs ...int) int {
n := 0
for _, x := range xs {
n += x
}
return n
}
func replaceFirst(xs ...int) {
if len(xs) > 0 {
xs[0] = 99
}
}
func deferred() (result int) {
n := 1
defer fmt.Println("argument:", n)
defer func() { fmt.Println("closure:", n); result++ }()
n = 2
return 10
}
func unnamed() int {
n := 10
defer func() { n++ }()
return n
}
func savedFunction() {
f := func() { fmt.Println("function: old") }
defer f()
f = func() { fmt.Println("function: new") }
_ = f
}
func loopValues() (fresh, shared []int) {
var a, b []func() int
for i := 0; i < 3; i++ {
a = append(a, func() int { return i })
}
var i int
for i = 0; i < 3; i++ {
b = append(b, func() int { return i })
}
for _, f := range a {
fresh = append(fresh, f())
}
for _, f := range b {
shared = append(shared, f())
}
return
}
func recoverHelper() any { return recover() }
func recoverNamed(events *[]string) {
*events = append(*events, "named: "+fmt.Sprint(recover()))
}
func indirect() (events []string) {
defer func() {
if p := recover(); p != nil {
events = append(events, "outer: "+fmt.Sprint(p))
}
}()
defer func() {
events = append(events, "helper: "+fmt.Sprint(recoverHelper()))
}()
panic("boom")
}
func recovery(kind string) (events []string) {
defer func() {
if p := recover(); p != nil {
events = append(events, "outer: "+fmt.Sprint(p))
}
}()
switch kind {
case "named":
defer recoverNamed(&events)
case "direct":
defer func() { events = append(events, "direct: "+fmt.Sprint(recover())) }()
case "helper":
defer func() { events = append(events, "helper: "+fmt.Sprint(recoverHelper())) }()
case "builtin":
defer recover()
}
panic("boom")
}
func deferScope() (events []string) {
for i := 0; i < 3; i++ {
defer func(n int) { events = append(events, fmt.Sprint("close ", n)) }(i)
events = append(events, fmt.Sprint("use ", i))
}
events = append(events, "loop done")
return
}
func deferPerItem() (events []string) {
for i := 0; i < 3; i++ {
func(n int) {
defer func() { events = append(events, fmt.Sprint("close ", n)) }()
events = append(events, fmt.Sprint("use ", n))
}(i)
}
return
}
func main() {
fmt.Println("twice:", applyTwice(3, func(n int) int { return n + 2 }))
q, r := divmod(17, 5)
fmt.Println("divmod:", q, r)
var absent Transform
fmt.Println("nil function:", absent == nil)
a, b := counter(10), counter(10)
fmt.Println("counters:", a(), a(), b())
xs := []int{1, 2, 3}
fmt.Println("variadic:", total(), total(1, 2, 3), total(xs...))
replaceFirst(xs...)
fmt.Println("shared slice:", xs)
fmt.Println("named result:", deferred())
fmt.Println("unnamed result:", unnamed())
savedFunction()
fresh, shared := loopValues()
fmt.Println("loop declared:", fresh)
fmt.Println("loop reused:", shared)
fmt.Println("direct recovery:", recovery("direct"))
fmt.Println("helper recovery:", recovery("helper"))
fmt.Println("builtin recovery:", recovery("builtin"))
fmt.Println("function scope:", deferScope())
fmt.Println("per item:", deferPerItem())
}main_test.go
代码块 · 59 行 · 点击收起 / 展开
go
package main
import (
"reflect"
"testing"
)
func TestRecovery(t *testing.T) {
cases := []struct {
kind string
want []string
}{
{"direct", []string{"direct: boom"}},
{"named", []string{"named: boom"}},
{"helper", []string{"helper: <nil>", "outer: boom"}},
{"builtin", []string{"outer: boom"}},
}
for _, tc := range cases {
if got := recovery(tc.kind); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("%s: %v", tc.kind, got)
}
}
}
func TestIndirectSnippet(t *testing.T) {
if got := indirect(); !reflect.DeepEqual(got, []string{"helper: <nil>", "outer: boom"}) {
t.Fatal(got)
}
}
func TestNilFunctionPanicTiming(t *testing.T) {
registered := false
defer func() {
if recover() == nil || !registered {
t.Fatal("nil deferred call must panic at execution")
}
}()
var f func()
defer f()
registered = true
}
func TestLoopsAndClosures(t *testing.T) {
a, b := loopValues()
if !reflect.DeepEqual(a, []int{0, 1, 2}) || !reflect.DeepEqual(b, []int{3, 3, 3}) {
t.Fatal(a, b)
}
c, d := counter(0), counter(0)
if c() != 1 || c() != 2 || d() != 1 {
t.Fatal("independent counters")
}
}
func TestVariadicSharing(t *testing.T) {
if total() != 0 {
t.Fatal("zero arguments")
}
xs := []int{1, 2}
replaceFirst(xs...)
if xs[0] != 99 {
t.Fatal(xs)
}
}testdata/compare/main.go
go
package main
func main() { f := func() {}; _ = f == f }testdata/mixed/main.go
go
package main
func sum(xs ...int) {}
func main() { xs := []int{2, 3}; sum(1, xs...) }testdata/anyslice/main.go
go
package main
import "fmt"
func main() { xs := []string{"a", "b"}; fmt.Println(xs...) }Go 官方:Defer, Panic, and Recover,关于包对外返回显式错误的惯例。 ↩︎